+* +* @author Franc[e]sco (lolisamurai@tfwno.gf) +*/ +public final class Koohii { + +private Koohii() {} + +public final int VERSION_MAJOR = 1; +public final int VERSION_MINOR = 2; +public final int VERSION_PATCH = 0; + +/** prints a message to stderr. */ +public static +void info(String fmt, Object... args) { + System.err.printf(fmt, args); +} + +/* ------------------------------------------------------------- */ +/* math */ + +/** 2D vector with double values */ +public static class Vector2 +{ + public double x = 0.0, y = 0.0; + + public Vector2() {} + public Vector2(Vector2 other) { this(other.x, other.y); } + public Vector2(double x, double y) { this.x = x; this.y = y; } + + public String toString() { + return String.format("(%s, %s)", x, y); + } + + /** + * this -= other . + * @return this + */ + public Vector2 sub(Vector2 other) + { + x -= other.x; y -= other.y; + return this; + } + + /** + * this *= value . + * @return this + */ + public Vector2 mul(double value) + { + x *= value; y *= value; + return this; + } + + /** length (magnitude) of the vector. */ + public double len() { return Math.sqrt(x * x + y * y); } + + /** dot product between two vectors, correlates with the angle */ + public double dot(Vector2 other) { return x * other.x + y * other.y; } +} + +/* ------------------------------------------------------------- */ +/* beatmap utils */ + +public static final int MODE_STD = 0; +public static final int MODE_TK = 1; + +public static class Circle +{ + public Vector2 pos = new Vector2(); + public String toString() { return pos.toString(); } +} + +public static class Slider +{ + public Vector2 pos = new Vector2(); + + /** distance travelled by one repetition. */ + public double distance = 0.0; + + /** 1 = no repeats. */ + public int repetitions = 1; + + public String toString() + { + return String.format( + "{ pos=%s, distance=%s, repetitions=%d }", + pos, distance, repetitions + ); + } +} + +public static final int OBJ_CIRCLE = 1<<0; +public static final int OBJ_SLIDER = 1<<1; +public static final int OBJ_SPINNER = 1<<3; + +/** strain index for speed */ +public final static int DIFF_SPEED = 0; + +/** strain index for aim */ +public final static int DIFF_AIM = 1; + +public static class HitObject +{ + /** start time in milliseconds. */ + public double time = 0.0; + public int type = OBJ_CIRCLE; + + /** an instance of Circle or Slider or null. */ + public Object data = null; + public Vector2 normpos = new Vector2(); + public double angle = 0.0; + public final double[] strains = new double[] { 0.0, 0.0 }; + public boolean is_single = false; + public double delta_time = 0.0; + public double d_distance = 0.0; + + /** string representation of the type bitmask. */ + public String typestr() + { + StringBuilder res = new StringBuilder(); + + if ((type & OBJ_CIRCLE) != 0) res.append("circle | "); + if ((type & OBJ_SLIDER) != 0) res.append("slider | "); + if ((type & OBJ_SPINNER) != 0) res.append("spinner | "); + + String result = res.toString(); + return result.substring(0, result.length() - 3); + } + + public String toString() + { + return String.format( + "{ time=%s, type=%s, data=%s, normpos=%s, " + + "strains=[ %s, %s ], is_single=%s }", + time, typestr(), data, normpos, strains[0], strains[1], + is_single + ); + } +} + +public static class Timing +{ + /** start time in milliseconds. */ + public double time = 0.0; + public double ms_per_beat = -100.0; + + /** if false, ms_per_beat is -100 * bpm_multiplier. */ + public boolean change = false; +} + +/** +* the bare minimum beatmap data for difficulty calculation. +* +* this object can be reused for multiple beatmaps without +* re-allocation by simply calling reset() +*/ +public static class Map +{ + public int format_version; + public int mode; + public String title, title_unicode; + public String artist, artist_unicode; + + /** mapper name. */ + public String creator; + + /** difficulty name. */ + public String version; + + public int ncircles, nsliders, nspinners; + public float hp, cs, od, ar; + public float sv, tick_rate; + + public final ArrayList+* this is meant to be a single file library that's as portable and +* easy to set up as possible for java projects that need +* pp/difficulty calculation. +* +* when running the test suite, speed is roughly equivalent to the C +* implementation, but peak memory usage is almost 80 times higher. +* if you are on a system with limited resources or you don't want +* to spend time installing and setting up java, you can use the C +* implementation which doesn't depend on any third party software. +* ----------------------------------------------------------------- +* usage: +* put Koohii.java in your project's folder +* ----------------------------------------------------------------- +* import java.io.BufferedReader; +* import java.io.InputStreamReader; +* +* class Example { +* +* public static void main(String[] args) throws java.io.IOException +* { +* BufferedReader stdin = +* new BufferedReader(new InputStreamReader(System.in) +* ); +* +* Koohii.Map beatmap = new Koohii.Parser().map(stdin); +* Koohii.DiffCalc stars = new Koohii.DiffCalc().calc(beatmap); +* System.out.printf("%s stars\n", stars.total); +* +* Koohii.PPv2 pp = Koohii.PPv2( +* stars.aim, stars.speed, beatmap +* ); +* +* System.out.printf("%s pp\n", pp.total); +* } +* +* } +* ----------------------------------------------------------------- +* javac Example.java +* cat /path/to/file.osu | java Example +* ----------------------------------------------------------------- +* this is free and unencumbered software released into the +* public domain. +* +* refer to the attached UNLICENSE or http://unlicense.org/ +*
+* +* @param mapstats the base beatmap stats +* @param flags bitmask that specifies which stats to modify. only +* the stats specified here need to be initialized in +* mapstats. +* @return mapstats +* @see MapStats +*/ +public static +MapStats mods_apply(int mods, MapStats mapstats, int flags) +{ + if ((mods & MODS_MAP_CHANGING) == 0) { + return mapstats; + } + + if ((mods & (MODS_DT | MODS_NC)) != 0) { + mapstats.speed = 1.5f; + } + + if ((mods & MODS_HT) != 0) { + mapstats.speed *= 0.75f; + } + + float od_ar_hp_multiplier = 1.0f; + + if ((mods & MODS_HR) != 0) { + od_ar_hp_multiplier = 1.4f; + } + + if ((mods & MODS_EZ) != 0) { + od_ar_hp_multiplier *= 0.5f; + } + + if ((flags & APPLY_AR) != 0) + { + mapstats.ar *= od_ar_hp_multiplier; + + /* convert AR into milliseconds window */ + double arms = mapstats.ar < 5.0f ? + AR0_MS - AR_MS_STEP1 * mapstats.ar + : AR5_MS - AR_MS_STEP2 * (mapstats.ar - 5.0f); + + /* stats must be capped to 0-10 before HT/DT which brings + them to a range of -4.42->11.08 for OD and -5->11 for AR */ + arms = Math.min(AR0_MS, Math.max(AR10_MS, arms)); + arms /= mapstats.speed; + + mapstats.ar = (float)( + arms > AR5_MS ? + (AR0_MS - arms) / AR_MS_STEP1 + : 5.0 + (AR5_MS - arms) / AR_MS_STEP2 + ); + } + + if ((flags & APPLY_OD) != 0) + { + mapstats.od *= od_ar_hp_multiplier; + double odms = OD0_MS - Math.ceil(OD_MS_STEP * mapstats.od); + odms = Math.min(OD0_MS, Math.max(OD10_MS, odms)); + odms /= mapstats.speed; + mapstats.od = (float)((OD0_MS - odms) / OD_MS_STEP); + } + + if ((flags & APPLY_CS) != 0) + { + if ((mods & MODS_HR) != 0) { + mapstats.cs *= 1.3f; + } + + if ((mods & MODS_EZ) != 0) { + mapstats.cs *= 0.5f; + } + + mapstats.cs = Math.min(10.0f, mapstats.cs); + } + + if ((flags & APPLY_HP) != 0) + { + mapstats.hp = + Math.min(10.0f, mapstats.hp * od_ar_hp_multiplier); + } + + return mapstats; +} + +/* ------------------------------------------------------------- */ +/* difficulty calculator */ + +/** +* arbitrary thresholds to determine when a stream is spaced +* enough that it becomes hard to alternate. +*/ +private final static double SINGLE_SPACING = 125.0; + +/** strain decay per interval. */ +private final static double[] DECAY_BASE = { 0.3, 0.15 }; + +/** balances speed and aim. */ +private final static double[] WEIGHT_SCALING = { 1400.0, 26.25 }; + +/** +* max strains are weighted from highest to lowest, this is how +* much the weight decays. +*/ +private final static double DECAY_WEIGHT = 0.9; + +/** +* strains are calculated by analyzing the map in chunks and taking +* the peak strains in each chunk. this is the length of a strain +* interval in milliseconds +*/ +private final static double STRAIN_STEP = 400.0; + +/** non-normalized diameter where the small circle buff starts. */ +private final static double CIRCLESIZE_BUFF_THRESHOLD = 30.0; + +/** global stars multiplier. */ +private final static double STAR_SCALING_FACTOR = 0.0675; + +/** in osu! pixels */ +private final static double PLAYFIELD_WIDTH = 512.0, + PLAYFIELD_HEIGHT = 384.0; + +private final static Vector2 PLAYFIELD_CENTER = new Vector2( + PLAYFIELD_WIDTH / 2.0, PLAYFIELD_HEIGHT / 2.0 +); + +/** +* 50% of the difference between aim and speed is added to total +* star rating to compensate for aim/speed only maps +*/ +private final static double EXTREME_SCALING_FACTOR = 0.5; + +private final static double MIN_SPEED_BONUS = 75.0; +private final static double MAX_SPEED_BONUS = 45.0; +private final static double ANGLE_BONUS_SCALE = 90.0; +private final static double AIM_TIMING_THRESHOLD = 107; +private final static double SPEED_ANGLE_BONUS_BEGIN = 5 * Math.PI / 6; +private final static double AIM_ANGLE_BONUS_BEGIN = Math.PI / 3; + +private static +double d_spacing_weight(int type, double distance, double delta_time, + double prev_distance, double prev_delta_time, double angle) +{ + double strain_time = Math.max(delta_time, 50.0); + double prev_strain_time = Math.max(prev_delta_time, 50.0); + double angle_bonus; + switch (type) + { + case DIFF_AIM: { + double result = 0.0; + if (!Double.isNaN(angle) && angle > AIM_ANGLE_BONUS_BEGIN) { + angle_bonus = Math.sqrt( + Math.max(prev_distance - ANGLE_BONUS_SCALE, 0.0) * + Math.pow(Math.sin(angle - AIM_ANGLE_BONUS_BEGIN), 2.0) * + Math.max(distance - ANGLE_BONUS_SCALE, 0.0) + ); + result = ( + 1.5 * Math.pow(Math.max(0.0, angle_bonus), 0.99) / + Math.max(AIM_TIMING_THRESHOLD, prev_strain_time) + ); + } + double weighted_distance = Math.pow(distance, 0.99); + return Math.max(result + + weighted_distance / + Math.max(AIM_TIMING_THRESHOLD, strain_time), + weighted_distance / strain_time); + } + + case DIFF_SPEED: { + distance = Math.min(distance, SINGLE_SPACING); + delta_time = Math.max(delta_time, MAX_SPEED_BONUS); + double speed_bonus = 1.0; + if (delta_time < MIN_SPEED_BONUS) { + speed_bonus += + Math.pow((MIN_SPEED_BONUS - delta_time) / 40.0, 2); + } + angle_bonus = 1.0; + if (!Double.isNaN(angle) && angle < SPEED_ANGLE_BONUS_BEGIN) { + double s = Math.sin(1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)); + angle_bonus += Math.pow(s, 2) / 3.57; + if (angle < Math.PI / 2.0) { + angle_bonus = 1.28; + if (distance < ANGLE_BONUS_SCALE && angle < Math.PI / 4.0) { + angle_bonus += (1.0 - angle_bonus) * + Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0); + } + } else if (distance < ANGLE_BONUS_SCALE) { + angle_bonus += (1.0 - angle_bonus) * + Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0) * + Math.sin((Math.PI / 2.0 - angle) * 4.0 / Math.PI); + } + } + return ( + (1 + (speed_bonus - 1) * 0.75) * angle_bonus * + (0.95 + speed_bonus * Math.pow(distance / SINGLE_SPACING, 3.5)) + ) / strain_time; + } + } + + throw new UnsupportedOperationException( + "this difficulty type does not exist" + ); +} + +/** +* calculates the strain for one difficulty type and stores it in +* obj. this assumes that normpos is already computed. +* this also sets is_single if type is DIFF_SPEED +*/ +private static +void d_strain(int type, HitObject obj, HitObject prev, + double speed_mul) +{ + double value = 0.0; + double time_elapsed = (obj.time - prev.time) / speed_mul; + double decay = + Math.pow(DECAY_BASE[type], time_elapsed / 1000.0); + + obj.delta_time = time_elapsed; + + /* this implementation doesn't account for sliders */ + if ((obj.type & (OBJ_SLIDER | OBJ_CIRCLE)) != 0) + { + double distance = + new Vector2(obj.normpos).sub(prev.normpos).len(); + obj.d_distance = distance; + + if (type == DIFF_SPEED) { + obj.is_single = distance > SINGLE_SPACING; + } + + value = d_spacing_weight(type, distance, time_elapsed, + prev.d_distance, prev.delta_time, obj.angle); + value *= WEIGHT_SCALING[type]; + } + + obj.strains[type] = prev.strains[type] * decay + value; +} + +/** +* difficulty calculator, can be reused in subsequent calc() calls. +*/ +public static class DiffCalc +{ + /** star rating. */ + public double total; + + /** aim stars. */ + public double aim; + + /** aim difficulty (used to calc length bonus) */ + public double aim_difficulty; + + /** aim length bonus (unused at the moment) */ + public double aim_length_bonus; + + /** speed stars. */ + public double speed; + + /** speed difficulty (used to calc length bonus) */ + public double speed_difficulty; + + /** speed length bonus (unused at the moment) */ + public double speed_length_bonus; + + /** + * number of notes that are considered singletaps by the + * difficulty calculator. + */ + public int nsingles; + + /** + * number of taps slower or equal to the singletap threshold + * value. + */ + public int nsingles_threshold; + + /** + * the beatmap we want to calculate the difficulty for. + * must be set or passed to calc() explicitly. + * persists across calc() calls unless it's changed or explicity + * passed to calc() + * @see DiffCalc#calc(Koohii.Map, int, double) + * @see DiffCalc#calc(Koohii.Map, int) + * @see DiffCalc#calc(Koohii.Map) + */ + public Map beatmap = null; + + private double speed_mul; + private final ArrayList+* Koohii.MapStats mapstats = new Koohii.MapStats(); +* mapstats.ar = 9; +* Koohii.mods_apply(Koohii.MODS_DT, mapstats, Koohii.APPLY_AR); +* // mapstats.ar is now 10.33, mapstats.speed is 1.5 +*